<?php
namespace Martdb\Utils;

use Martdb\Enums\Types;
use Martdb\Libs\ThinUtil;
use Martdb\Exceptions\TIllegalArgumentException;
use Martdb\Exceptions\TIndexOutOfBoundsException;
use Martdb\Exceptions\MismatchTypeException;

/**
 * ==================================================
 * The Class is Input stream
 * ==================================================
 */
class CodedInputStream
{
    private $input = "";  // PHP Thrift binary type is string
    private $position = 0;
    private $limit = 0;
    
    public function __construct($rd) {
        if (!is_string($rd)) {
            throw new TIllegalArgumentException("Parameter must be string");
        }
        $this->input = $rd;
        $this->limit = strlen($rd);
    }
    
    /** Read a single byte. */
    public function readRawByte() {
        if ($this->position == $this->limit) {
            throw new TIndexOutOfBoundsException("Stream is EOF");
        }
        $value = ord(substr($this->input, $this->position++, 1));
        return $value > 127 ? $value - 256 : $value;
    }
    
    public function readRawBytes($size) {
        if ($size <= 0 || $this->isAtEnd()) {
            return array();
        }
        
        $bytes = array();
        $stream = $this->input;
        $length = min($size, $this->limit - $this->position);
        for ($i = $this->position, $j = 0; $j < $length; $i++) {
            $value = ord(substr($stream, $i, 1));
            $bytes[$j++] = $value > 127 ? $value - 256 : $value;
        }
        $this->position += $length;
        
        return $bytes;
    }
    
    /** Returns true if the stream has reached the end of the input. */
    public function isAtEnd() {
        return $this->position == $this->limit;
    }
    
    /** Read a {@code bool} field, including tag, to the stream. */
    public function readBool() {
        return $this->readBoolValue($this->readRawByte());
    }
    
    public function readBoolValue($tag) {
        switch ($tag) {
            case Types::BOOLEAN_TRUE: return true;
            case Types::BOOLEAN_FALSE: return false;
            default: throw new MismatchTypeException("Current byte is not Boolean");
        }
    }
    
    /** Read a {@code I32/int} field, including tag, to the stream. */
    public function readI32() {
        return $this->readI32Value($this->readRawByte());
    }
    
    public function readI32Value($tag) {
        switch ($tag & 0xf0) {
            case Types::I32: {
                $size = $this->computeNumberSize($tag);
                return $this->getNumber($size, (($tag & 0x08)) == 8);
            }
            default: throw new MismatchTypeException("Current byte is not I32/Int");
        }
    }
    
    /** Read a {@code I64/long} field, including tag, to the stream. */
    public function readI64() {
        return $this->readI64Value($this->readRawByte());
    }
    
    public function readI64Value($tag) {
        switch ($tag & 0xf0) {
            case Types::I64: {
                $size = $this->computeNumberSize($tag);
                return $this->getNumber($size, (($tag & 0x08)) == 8);
            }
            default: throw new MismatchTypeException("Current byte is not I64/Long");
        }
    }
    
    /** Read a {@code float} field, including tag, to the stream. */
    public function readFloat() {
        return $this->readFloatValue($this->readRawByte());
    }
    
    public function readFloatValue($tag) {
        switch ($tag & 0xf0) {  // or $tag, because float type > 0
            case Types::FLOAT: {
                return $this->readFloatLE(); // fixed32
            }
            default: throw new MismatchTypeException("Current byte is not Float");
        }
    }
    
    /** Read a {@code double} field, including tag, to the stream. */
    public function readDouble() {
        return $this->readDoubleValue($this->readRawByte());
    }
    
    public function readDoubleValue($tag) {
        switch ($tag) {
            case Types::DOUBLE: {
                return $this->readDoubleLE(); // fixed64
            }
            default: throw new MismatchTypeException("Current byte is not Double");
        }
    }
    
    /** Read a {@code string} field, including tag, to the stream. */
    public function readString() {
        return $this->readStringValue($this->readRawByte());
    }
    
    public function readStringValue($tag) {
        switch (ThinUtil::toByte($tag & 0xf0)) {  // reference type is negative number
            case Types::STRING: {
                $size = $this->computeRefSize($tag);
                if ($size == 0) {
                    return "";
                }
                $len = $this->getNumber($size, false);
                //$bytes = $this->readRawBytes($len);
                $str = substr($this->input, $this->position, $len);
                $this->position += $len;
                return $str;
            }
            default: throw new MismatchTypeException("Current byte is not String");
        }
    }
    
    /** Read a {@code byte array} field, including tag, to the stream. */
    public function readBytes() {
        return $this->readBytesValue($this->readRawByte());
    }
    
    public function readBytesValue($tag) {
        switch (ThinUtil::toByte($tag & 0xf0)) {  // reference type is negative number
            case Types::BYTES: {
                $size = $this->computeRefSize($tag);
                if ($size == 0) {
                    return array();
                }
                $len = $this->getNumber($size, false);
                return $this->readRawBytes($len);
            }
            default: throw new MismatchTypeException("Current byte is not ByteArray");
        }
    }
    
    /** Read a {@code collection} field, including tag, to the stream. */
    public function readCollection() {
        return $this->readCollectionValue($this->readRawByte());
    }
    
    public function readCollectionValue($tag) {
        switch (ThinUtil::toByte($tag & 0xf0)) {  // reference type is negative number
            case Types::LIST: {
                $size = $this->computeRefSize($tag);
                if ($size == 0) {
                    return new TList();
                }
                
                $len = $this->getNumber($size, false);
                
                $collection = new TList();
                for ($i = 0; $i < $len; $i++) {
                    $collection->add($this->getObject());
                }
                return $collection;
            }
            default: throw new MismatchTypeException(
            "Current byte is not Collection and collections only support List");
        }
    }
    
    /** Read a {@code map} field, including tag, to the stream. */
    public function readMap() {
        return $this->readMapValue($this->readRawByte());
    }
    
    public function readMapValue($tag) {
        switch (ThinUtil::toByte($tag & 0xf0)) {  // reference type is negative number
            case Types::MAP: {
                $size = $this->computeRefSize($tag);
                if ($size == 0) {
                    return new TMap();
                }
                
                $len = $this->getNumber($size, false);
                
                $map = new TMap();
                for ($i = 0; $i < $len; $i++) {
                    $map->put($this->getObject(), $this->getObject());
                }
                return $map;
            }
            default: throw new MismatchTypeException("Current byte is not Map");
        }
    }
    
    /** Read a {@code NULL} field, including tag, to the stream. */
    public function readNull() {
        return $this->readNullValue($this->readRawByte());
    }
    
    public function readNullValue($tag) {
        switch (ThinUtil::toByte($tag & 0xf0)) {
            case Types::NULL: return null;
            default: throw new MismatchTypeException("Current byte is not NULL");
        }
    }
    
    /** Read a {@code object} field, including tag, to the stream. */
    public function getObject() {
        $tag = $this->readRawByte();
        
        switch (ThinUtil::toByte($tag)) {
            case Types::BOOLEAN_TRUE: return true;
            case Types::BOOLEAN_FALSE: return false;
            case Types::NULL: return null;
        }
        
        switch (ThinUtil::toByte($tag & 0xf0)) {
            case Types::I32: return $this->readI32Value($tag);
            case Types::I64: return $this->readI64Value($tag);
            case Types::FLOAT: return $this->readFloatValue($tag);
            case Types::DOUBLE: return $this->readDoubleValue($tag);
            case Types::STRING: return $this->readStringValue($tag);
            case Types::BYTES: return $this->readBytesValue($tag);
            case Types::LIST: return $this->readCollectionValue($tag);
            case Types::MAP: return $this->readMapValue($tag);
            case Types::NULL: return $this->readNullValue($tag);
            default: throw new MismatchTypeException("type is not supported - " + $tag);
        }
    }
    
    /** Float.intBitsToFloat(readRawLittleEndian32()); */
    private function readFloatLE() {
        $value = 0;
        for ($i = 0; $i < 4; $i++) {
            $b = $this->readRawByte();
            $b = $b < 0 ? $b + 256 : $b;  // uint32(b)
            $value = $value | $b << ($i * 8);
        }
        return unpack('f*', pack('l', $value))[1];
    }
    
    /** Double.longBitsToDouble(readRawLittleEndian64()); */
    private function readDoubleLE() {
        $value = 0;
        for ($i = 0; $i < 8; $i++) {
            $b = $this->readRawByte();
            $b = $b < 0 ? $b + 256 : $b;  // uint32(b)
            $value = $value | $b << ($i * 8);
        }
        
        $upper = ($value >> 32) & 0xffffffff;
        $lower = $value & 0xffffffff;
        
        $real = ThinUtil::isLittleEndian()
        ? unpack('d*', pack('LL', $lower, $upper))[1]
        : unpack('d*', pack('LL', $upper, $lower))[1];
        return $real;
    }
    
    /**
     * read Number 32/64-bit from stream<br/>
     * little-endian
     */
    public function getNumber($size, $isNeg) {
        if ($size > 8 || $size < 1) {
            throw new TIllegalArgumentException("Int byte length must be in the range of 1-4");
        }
        $x = 0;
        $value = $this->readRawByte() & 0xff;
        for ($i = 1; $i < $size; $i++) {
            $x = ($x << 8) | 0xff;
            $value = (($this->readRawByte() & 0xff) << ($i * 8)) | ($value & $x); // note: 0xffL
        }
        return $isNeg ? -$value - 1 : $value;
    }
    
    /**
     * Compute number size<br/>
     * 000 = 1, 001 = 2, ..., 111 = 8
     */
    public function computeNumberSize($tag) {
        switch (($tag & 0x7)) {
            case 0 : return 1;
            case 1 : return 2;
            case 2 : return 3;
            case 3 : return 4;
            case 4 : return 5;
            case 5 : return 6;
            case 6 : return 7;
            default : return 8;
        }
    }
    
    /**
     * Compute reference size<br/>
     * 000 = 0, 001 = 1, ..., 100 = 4
     */
    public function computeRefSize($tag) {
        switch (($tag & 0x7)) {
            case 0 : return 0;
            case 1 : return 1;
            case 2 : return 2;
            case 3 : return 3;
            default : return 4;
        }
    }
}